第11章 特征工程之数据预处理¶

  • 在实际工作中获取到的数据往往不那么理想,可能会存在非数值类型的文本数据、重复值、缺失值、异常值及数据分布不均衡等问题,因此,在进行数学建模前还需要对这些问题进行处理,这项工作称为特征工程

  • 特征工程通常分为特征使用方案、特征获取方案、特征处理、特征监控几大部分,其中特征处理是特征工程的核心内容,有时称为数据预处理

11.1 非数值类型数据处理¶

  • 机器学习建模时处理的都是数值类型的数据,然而实际工作中获取的数据往往会包含非数值类型的数据,其中最常见的就是文本类型的数据, 例如性别中的 '男' 和 '女'

11.1.1 Get_dummies哑变量处理¶

  • 哑变量也叫虚拟变量,通常取值为0或1,将性别中的“男”和“女”分别转换成数字1和0就是哑变量最经典的应用
In [1]:
import pandas as pd
df = pd.DataFrame({'客户编号': [1, 2, 3], '性别': ['男', '女', '男']})
df
Out[1]:
客户编号 性别
0 1 男
1 2 女
2 3 男
In [2]:
df = pd.get_dummies(df, columns=['性别'], dtype=int)
df
Out[2]:
客户编号 性别_女 性别_男
0 1 0 1
1 2 1 0
2 3 0 1
In [3]:
df = df.drop(columns='性别_女') 
In [4]:
df
Out[4]:
客户编号 性别_男
0 1 1
1 2 0
2 3 1
In [5]:
import pandas as pd
df = pd.DataFrame({'房屋编号': [1, 2, 3, 4, 5], '朝向': ['东', '南', '西', '北', '南']})
df
Out[5]:
房屋编号 朝向
0 1 东
1 2 南
2 3 西
3 4 北
4 5 南
In [6]:
df = pd.get_dummies(df, columns=['朝向'], dtype = int)
df
Out[6]:
房屋编号 朝向_东 朝向_北 朝向_南 朝向_西
0 1 1 0 0 0
1 2 0 0 1 0
2 3 0 0 0 1
3 4 0 1 0 0
4 5 0 0 1 0
In [7]:
df = df.drop(columns='朝向_西') 
In [8]:
df
Out[8]:
房屋编号 朝向_东 朝向_北 朝向_南
0 1 1 0 0
1 2 0 0 1
2 3 0 0 0
3 4 0 1 0
4 5 0 0 1

11.1.2 Label Encoding编号处理¶

In [9]:
import pandas as pd
df = pd.DataFrame({'编号': [1, 2, 3, 4, 5], '城市': ['北京', '上海', '广州', '深圳', '北京']})

df
Out[9]:
编号 城市
0 1 北京
1 2 上海
2 3 广州
3 4 深圳
4 5 北京
In [10]:
from sklearn.preprocessing import LabelEncoder
le = LabelEncoder() # 将LabelEncoder()函数赋给变量le
label = le.fit_transform(df['城市']) # 用fit_transform()函数将待转化的列传入模型中进行拟合,并将结果赋给变量label
In [11]:
print(label)
[1 0 2 3 1]
In [12]:
df['城市'] = label

df
Out[12]:
编号 城市
0 1 1
1 2 0
2 3 2
3 4 3
4 5 1

补充知识点: pandas库中的replace()函数¶

In [13]:
df = pd.DataFrame({'编号': [1, 2, 3, 4, 5], '城市': ['北京', '上海', '广州', '深圳', '北京']})
In [14]:
df['城市'].value_counts()
Out[14]:
城市
北京    2
上海    1
广州    1
深圳    1
Name: count, dtype: int64
In [15]:
import warnings

warnings.filterwarnings('ignore')
In [16]:
df['城市'] = df['城市'].replace({'北京': 0, '上海': 1, '广州': 2, '深圳':3})
df
Out[16]:
编号 城市
0 1 0
1 2 1
2 3 2
3 4 3
4 5 0
  • Get_dummies的优点就是它的值只有0和1,缺点是当类别的数量很多时,特征维度会很高,我们可以配合使用下一章即将讲到的PCA主成分分析来减少维度, 所以如果Get_dummies类别数目不多时可以优先考虑

  • 其次考虑Label Encoding或replace()函数,但如果是基于树模型的机器学习模型,用Label Encoding也没有太大关系。

11.2 重复值、缺失值及异常值处理¶

11.2.1 重复值处理¶

In [17]:
# 这里首先创建一个含有重复值的DataFrame,代码如下:
import pandas as pd
data = pd.DataFrame([[1, 2, 3], [1, 2, 3], [4, 5, 6]], columns=['c1', 'c2', 'c3'])
In [18]:
# 此时的data二维列表如下所示,可以看到第一行和第二行是重复的。
data
Out[18]:
c1 c2 c3
0 1 2 3
1 1 2 3
2 4 5 6
In [19]:
# 如果数据量较大,我们可以通过duplicated()函数来查询重复的内容,代码如下:
data[data.duplicated()]
Out[19]:
c1 c2 c3
1 1 2 3
In [20]:
# 如果想统计重复行的数量,可以通过sum()函数进行查看,代码如下,本案例结果为1。
data.duplicated().sum()
Out[20]:
1
In [21]:
# 发现有重复行的时候,可以通过drop_duplicates()函数删除重复行,代码如下:
data = data.drop_duplicates()
In [22]:
data
Out[22]:
c1 c2 c3
0 1 2 3
2 4 5 6
In [23]:
# 如果想按列进行去重,比如说如果c1列出现相同的内容,可以采用如下代码。这样的筛选条件则不如之前要全部一样才删除严格。
data = pd.DataFrame([[1, 2, 3], [1, 2, 3], [4, 5, 6]], columns=['c1', 'c2', 'c3'])
data = data.drop_duplicates('c1')
data
Out[23]:
c1 c2 c3
0 1 2 3
2 4 5 6

11.2.2 缺失值处理¶

In [24]:
# 这里先构造一个含有缺失值的DataFrame,代码如下:
import numpy as np
data = pd.DataFrame([[1, np.nan, 3], [np.nan, 2, np.nan], [1, np.nan, 0]], columns=['c1', 'c2', 'c3'])
data
Out[24]:
c1 c2 c3
0 1.0 NaN 3.0
1 NaN 2.0 NaN
2 1.0 NaN 0.0
In [25]:
# 可以用isnull()函数或isna()函数(两者作用类似)来查看空值,代码如下:
data.isnull()  # 或者写data.isna()
Out[25]:
c1 c2 c3
0 False True False
1 True False True
2 False True False
In [26]:
# 也可以对单列查看缺失值情况,代码如下:
data['c1'].isnull()
Out[26]:
0    False
1     True
2    False
Name: c1, dtype: bool
In [27]:
# 如果数据量较大,可以通过如下代码筛选某列内容为空值的行,代码如下:
data[data['c1'].isnull()]
Out[27]:
c1 c2 c3
1 NaN 2.0 NaN
In [28]:
# 对于空值有两种常见的处理方式:删除空值和填补空值。
# 通过dropna()函数可以删除空值,代码如下:
a = data.dropna()
a
Out[28]:
c1 c2 c3
In [29]:
# 如果觉得该删除方法过于激进,可以设置thresh参数,比如将其设置为n,那么其含义是如果该行的非空值少于n个则删除该行,演示代码如下:
a = data.dropna(thresh=2)
a
Out[29]:
c1 c2 c3
0 1.0 NaN 3.0
2 1.0 NaN 0.0
In [30]:
# 通过finllna()函数可以填补空值,这里采用的是均值填充法,通过每一列的均值对该列的空值进行填充,也可以把其中的data.mean()换成data.meian()则变为中位数填充。
b = data.fillna(data.mean())
b
Out[30]:
c1 c2 c3
0 1.0 2.0 3.0
1 1.0 2.0 1.5
2 1.0 2.0 0.0
In [31]:
# 此处method='pad'代表用缺失值所在列的前一个值填充,如果前一个值不存在或也缺失,则结果不变。运行结果如下:
c = data.fillna(method='pad')
c
Out[31]:
c1 c2 c3
0 1.0 NaN 3.0
1 1.0 2.0 3.0
2 1.0 2.0 0.0
In [32]:
# 还可以采用method='backfill'或method='bfill'用缺失值所在列的后一个值填充,如果后一个值不存在或也缺失,则结果不变。
d = data.fillna(method='backfill')
e = data.fillna(method='bfill')

print(e)

print(d)
    c1   c2   c3
0  1.0  2.0  3.0
1  1.0  2.0  0.0
2  1.0  NaN  0.0
    c1   c2   c3
0  1.0  2.0  3.0
1  1.0  2.0  0.0
2  1.0  NaN  0.0

11.2.3 异常值处理¶

In [33]:
# 这里先构造一个含有异常值的数据集:
data = pd.DataFrame({'c1': [3, 10, 5, 7, 1, 9, 69], 'c2': [15, 16, 14, 100, 19, 11, 8], 'c3': [20, 15, 18, 21, 120, 27, 29]}, columns=['c1', 'c2', 'c3'])
data
Out[33]:
c1 c2 c3
0 3 15 20
1 10 16 15
2 5 14 18
3 7 100 21
4 1 19 120
5 9 11 27
6 69 8 29
  • 可以看到第一列的数字69,第二列的数字100,第三列的数字120为比较明显的异常值

  • 异常值主要通过两种方法来进行检测:利用箱体图观察和利用标准差检测。

In [34]:
# 利用箱型图观察

data.boxplot()  # 画箱型图
Out[34]:
<Axes: >
No description has been provided for this image

将数据的下四分位数记为 $Q_1$ ,即样本中仅有 $25\%$ 的数据小于 $Q_1$ ;将数据的上四分位数记为 $Q_3$ ,即样本中仅有 $25\%$ 的数据大于 $Q_3$ ;将上四分位数和下四分位数的差值记为 $IQR$ ,即 $IQR=Q_3-Q_1$ ;令箱体图上界为 $Q_3+1.5\times IQR$,下界为 $Q_1-1.5\times IQR$

image.png

  • 当数据服从标准正态分布时,99%的数值与均值的距离应该在3个标准差之内,95%的数值与均值的距离应该在2个标准差之内

  • 因为3个标准差过于严格,此处将阈值设定为2个标准差,即认为当数值与均值的距离超出2个标准差,则可以认为它是异常值

    image.png

In [35]:
# 利用标准差检测

a = pd.DataFrame()
for i in data.columns: # 将每列数据进行 Z-score标准化
    z = (data[i] - data[i].mean()) / data[i].std()
    a[i] = abs(z) > 2 # 进行逻辑判断,如果Z-score标准化后的数值大于标准正态分布的标准差1的2倍,那么该数值为异常值,返回布尔值True,否则返回布尔值False

$$ x^* = \frac{x - mean}{std} $$

In [36]:
a
Out[36]:
c1 c2 c3
0 False False False
1 False False False
2 False False False
3 False True False
4 False False True
5 False False False
6 True False False
In [37]:
# 标记包含异常值的行
rows_with_outliers = a.any(axis=1) # axis=1 表示按行操作, any() 是检测是有存在至少一个True值
rows_with_outliers
Out[37]:
0    False
1    False
2    False
3     True
4     True
5    False
6     True
dtype: bool
In [38]:
data
Out[38]:
c1 c2 c3
0 3 15 20
1 10 16 15
2 5 14 18
3 7 100 21
4 1 19 120
5 9 11 27
6 69 8 29
In [39]:
# 删除包含异常值的行
data_cleaned = data[~rows_with_outliers]
data_cleaned
Out[39]:
c1 c2 c3
0 3 15 20
1 10 16 15
2 5 14 18
5 9 11 27

11.3 数据标准化¶

  • 数据标准化的主要目的是消除不同特征变量量纲级别相差太大造成的不利影响

    对于以特征距离为算法基础的机器学习方法(如K近邻算法), 数据标准化尤为重要

In [40]:
# 构造数据
import pandas as pd
X = pd.DataFrame({'酒精含量(%)': [50, 60, 40, 80, 90], '苹果酸含量(%)': [2, 1, 1, 3, 2]})
y = [0, 0, 0, 1, 1]

X  # 查看X
Out[40]:
酒精含量(%) 苹果酸含量(%)
0 50 2
1 60 1
2 40 1
3 80 3
4 90 2

11.3.1 min-max标准化¶

  • min-max标准化(Min-Max Normalization)也称离差标准化

    利用原始数据的最大值和最小值把原始数据转换到 $[0,1]$ 区间内, 转换公式为

    $$ x^* = \frac{x - \min}{\max - \min} $$

In [41]:
from sklearn.preprocessing import MinMaxScaler

X_new = MinMaxScaler().fit_transform(X)


X_new = pd.DataFrame(X_new, columns=['酒精含量', '苹果酸含量'])

X_new  # 查看X_new
Out[41]:
酒精含量 苹果酸含量
0 0.2 0.5
1 0.4 0.0
2 0.0 0.0
3 0.8 1.0
4 1.0 0.5
  • 在实际应用中,通常将所有数据都归一化后,再进行训练集和测试集划分
In [42]:
from sklearn.model_selection import train_test_split
X_train, X_test, y_train, y_test = train_test_split(X_new, y, test_size=0.2, random_state=123)

11.3.2 Z-score标准化¶

  • Z-score标准化也称均值归一化, 通过原始数据的均值(mean)和标准差(standard deviation)对数据进行标准化

  • 标准化后的数据符合标准正态分布, 公式为

    $$ x^* = \frac{x - mean}{std} $$

In [43]:
from sklearn.preprocessing import StandardScaler
X_new = StandardScaler().fit_transform(X)

X_new = pd.DataFrame(X_new, columns=['酒精含量', '苹果酸含量'])

X_new
Out[43]:
酒精含量 苹果酸含量
0 -0.754829 0.267261
1 -0.215666 -1.069045
2 -1.293993 -1.069045
3 0.862662 1.603567
4 1.401826 0.267261
  • 总结来说,数据标准化并不复杂,两三行代码就能避免很多问题,因此,对一些量纲相差较大的特征变量,实战中通常会先进行数据标准化,再进行训练集和测试集划分

  • 除了K近邻算法模型,还有一些模型也是基于距离的,所以量纲对模型影响较大,就需要进行数据标准化,如支持向量机模型、KMeans聚类分析、PCA(主成分分析)等。此外,对于一些线性模型,如第3章的线性回归模型和第4章的逻辑回归模型,有时也需要进行数据标准化处理

  • 对于树模型则无须做数据标准化处理,因为数值缩放不影响分裂点位置,对树模型的结构不造成影响。因此,决策树模型及基于决策树模型的随机森林模型、AdaBoost模型、GBDT模型、XGBoost模型、 LightGBM模型通常都不需要进行数据标准化处理,因为它们不关心变量的值,而是关心变量的分布和变量之间的条件概率

  • 在实际工作中,如果不确定是否要做数据标准化,可以先尝试做一做数据标准化,看看模型预测准确度是否有提升,如果提升较明显,则推荐进行数据标准化

11.4 数据分箱¶

  • 各种贷款业务机构普遍使用信用评分对客户进行评估,相应的模型称为信用评分卡模型

  • 在构建信用评分卡模型的过程中,需要利用WOE值和IV值进行特征筛选,而计算这两个值的第一步就是要进行数据分箱,本节先学习如何对连续型变量进行分箱处理

  • 数据分箱就是将一个连续型变量离散化,可分为等宽分箱和等深分箱

    • 等宽分箱是指每个分箱的差值相等,以“年龄”这一连续型特征变量为例,其取值范围为0~100的连续数值,可以将“年龄”分为0~20、20~40、40~60、60~80、80~100共5个分箱,这5个分箱就可以当成离散的分类变量,每个分箱的年龄差相等

    • 等深分箱是指每个分箱中的样本数一致,同样按“年龄”这一特征变量进行分箱,例如,500个样本分成5箱,那么每个分箱中都是100人,此时对应的5个分箱可能就是0~20、20~25、25~30、30~50、50~100,确保每个分箱中的人数一致

In [44]:
import pandas as pd
data = pd.DataFrame([[22,1],[25,1],[20,0],[35,0],[32,1],[38,0],[50,0],[46,1]], columns=['年龄', '是否违约'])
data
Out[44]:
年龄 是否违约
0 22 1
1 25 1
2 20 0
3 35 0
4 32 1
5 38 0
6 50 0
7 46 1
In [45]:
# 通过如下代码即可进行等宽数据分箱:
data_cut = pd.cut(x = data['年龄'], bins = 3) # 第1个参数是待分箱的列,第2个参数是分箱个数
print(data_cut)
0    (19.97, 30.0]
1    (19.97, 30.0]
2    (19.97, 30.0]
3     (30.0, 40.0]
4     (30.0, 40.0]
5     (30.0, 40.0]
6     (40.0, 50.0]
7     (40.0, 50.0]
Name: 年龄, dtype: category
Categories (3, interval[float64, right]): [(19.97, 30.0] < (30.0, 40.0] < (40.0, 50.0]]
  • “年龄”列中数据的范围是20~50岁,分为3组恰好为20~30岁(19.97近似为20)、30~40岁、40~50岁,可以看到,每个分箱的年龄差都是10岁,这就是等宽分箱
In [46]:
# 通过groupby()函数进行分组,count()函数(详见14.3节补充知识点)进行计数可以获取每个分箱中的样本数目,代码如下:
data['年龄'].groupby(data_cut).count()
Out[46]:
年龄
(19.97, 30.0]    3
(30.0, 40.0]     3
(40.0, 50.0]     2
Name: 年龄, dtype: int64
In [47]:
# 补充知识点,分箱并进行编号
print(pd.cut(data['年龄'], 3, labels=[1, 2, 3]))
0    1
1    1
2    1
3    2
4    2
5    2
6    3
7    3
Name: 年龄, dtype: category
Categories (3, int64): [1 < 2 < 3]
  • 学习数据分箱主要是为学习WOE值与IV值做准备,计算WOE值的第一个步骤就是数据分箱

11.5 特征筛选: WOE值与IV值¶

  • 在使用逻辑回归、决策树等模型算法构建分类模型时,经常需要对特征变量进行筛选

    因为有时可能会获得100多个候选特征变量,通常不会直接把这些特征变量放到模型中去进行拟合训练,而是从这些特征变量中挑选一些放进模型,构成入模变量列表

  • 挑选入模变量需要考虑很多因素,如变量的预测能力、简单性(容易生成和使用)、可解释性等

    其中最主要的衡量标准是变量的预测能力,对分类模型来说,即希望变量具有较好的特征区分度,可以较准确地将样本进行分类

  • WOE值和IV值就是这样的指标,它们可以用来衡量特征变量的预测能力,或者说特征变量的特征区分度,类似的指标还有5.1.2小节讲到的基尼系数和信息增益

    对于决策树等树模型来说,可以通过基尼系数或信息增益来衡量变量的特征区分度,而对于逻辑回归等没有基尼系数等指标的模型而言,可以通过WOE值和IV值进行变量选择

    IV值的计算是以WOE值为基础的,而要计算一个变量的WOE值,需要先对这个变量进行分箱处理

11.5.1 WOE值的定义与计算¶

  1. WOE值的定义

    WOE是Weight of Evidence(证据权重)的缩写, 其反映了某一变量的特征区分度

    要计算一个变量的WOE值, 需要先对这个变量进行分箱处理, 分箱后, 第 $i$ 个分箱内数据的WOE值得计算公式为

    $$ WOE_i = \ln(\frac{p_{y_i}}{p_{n_i}}) $$

    其中 $p_{y_i} = \frac{y_i}{y_T}$, $p_{n_i} = \frac{n_i}{n_T}$, 以客户违约预测模型为例来解释各个变量得含义: $p_{y_i}$ 是第 $i$ 个分箱中违约客户占样本中所有违约客户的比例, $p_{n_i}$ 是第 $i$ 个分箱中未违约客户占整个样本所有未违约客户的比例, $y_i$ 是第 $i$ 个分箱中违约客户的数量, $y_T$ 是整个样本中所有违约客户的数量, $n_i$ 是第 $i$ 个分箱中未违约客户的数量, $n_T$ 是整个样本中所有未违约客户的数量

    还可以对公式进行变换

    $$ WOE_i = \ln(\frac{p_{y_i}}{p_{n_i}}) = \ln(\frac{y_i/y_T}{n_i/n_T}) = \ln(\frac{y_i/n_i}{y_T/n_T}) $$

    这样也可以理解为分箱后第 $i$ 个分箱中违约客户和未违约客户的比值与整个样本该比值的差异; 其中 $y_T/n_T$ 是一个固定值, 所以WOE反映的就是分箱后第 $i$ 个分箱中违约客户和未违约客户的比值 $y_i/n_i$, 这其实就反映了特征区分度

  • 根据"年龄"这一特征变量分箱后, 各个分箱的WOE值(绝对值)都很大, 那么说明"年龄"这一特征变量的特征区分度很高, 能很好地区分违约与未违约客户, 因此这类问题应该重点考虑"年龄"这一特征变量

    image.png

    实际应用中, 因为数据量通常较大, 所以不太可能出现WOE值为 $\infty$ 的情况, 如果出现了无穷大的WOE值, 也是不希望看到的, 这样会导致基于WOE值的IV值也变成无穷大, 不利于进行特征筛选, 此时的处理方式有两种: 第一种方法是对数据重新进行合理的分箱, 使各个分箱的WOE值不再无穷大; 第二种方法是忽略这些无穷大的值, 直接让它变为0

  1. WOE值的计算过程演示

    演示数据:

    image.png

    对这个数据进行分箱, 并分别计算出每个分箱的 $p_{y_i}, p_{n_i}, WOE_i$, 结果为:

    image-2.png

    以第1个分箱为例, 20~30岁的人中有2人违约, 而整个样本中共有4人违约, 这 $p_{y_1} = 2/4 = 50\%$, 有1人未违约, 而整个样本中共有4人未违约, 则 $p_{n_1} = 1/4 = 25\%$, 因此 $WOE_1 = \ln(p_{y_1}/p_{n_1}) = \ln(0.5/0.25) = \ln 2$

11.5.2 IV值的定义与计算¶

  • 在实战应用中, 通过IV值可以评判特征变量的预测能力, 从而进行特征筛选
  1. IV值的定义

    IV是Information Value(信息量)的缩写, 在进行特征筛选时, IV值能较好地反映特征变量的预测能力, 特征变量对预测结果的贡献越大, 其价值就越大, 对应的IV值就越大, 因此可以根据IV值得大小筛选出需要的特征变量

    在计算一个特征变量的IV值之前, 需要先计算该变量各个分箱的IV值, 计算公式为:

    $$ IV_i = (p_{y_i} - p_{n_i})WOE_i = (p_{y_i} - p_{n_i})\ln(\frac{p_{y_i}}{p_{n_i}}) $$

    容易发现相乘的两项是同号的, 这样就保证了IV值的非负性, 使得其相加不至于正负相抵

    对各个分箱的IV值进行简单求和, 就得到这个特征变量的IV值:

    $$ IV = \sum_i^N IV_i $$

  1. IV值的计算过程演示

    仍以前表演示数据为例, 计算各个分箱的IV值:

    $$ \begin{aligned} IV_1 &= (p_{y_1} - p{n_1})WOE_1 = (\frac{1}{2} - \frac{1}{4}) \times \ln 2 = \frac{1}{4}\ln 2 \\ IV_2 &= (p_{y_2} - p{n_2})WOE_2 = (\frac{1}{4} - \frac{1}{2}) \times (-\ln 2) = \frac{1}{4}\ln 2 \\ IV_3 &= (p_{y_3} - p{n_3})WOE_3 = (\frac{1}{4} - \frac{1}{4}) \times 0 = 0 \\ \end{aligned} $$

    有了各个分箱的IV值, 就可以计算出"年龄"这一特征变量的IV值, 计算过程为:

    $$ IV = \sum_{i=1}^{3} IV_i = IV_1 + IV_2 + IV_3 = \frac{1}{4}\ln 2 + \frac{1}{4}\ln 2 + 0 = \frac{1}{2}\ln 2 $$

    通过这种方式对样本数据的每个特征变量进行IV值计算并排序后, 就可以获得特征变量的决策能力强弱信息

补充知识点: 使用IV值而不使用WOE值的原因¶

  • 从计算公式来看, WOE和IV包含了同样的信息($p_{y_i}, p_{n_i}$), 既然已经有了WOE值, 为什么还要创造一个IV值呢

    1. 人们习惯用一个大于等于0的数值去衡量预测能力, 而IV的计算公式保证了非负性

    2. 如果是因为正负的问题, 为什么不可以将各个分箱的WOE值的绝对值相加, 作为该特征变量的整体WOE值来衡量预测能力呢? 这是因为 ($p_{y_i} - p_{n_i}$) 作为权重因子可以体现分箱的数据量占整体的比例, 更精确地体现变量的预测能力

      为了说明第2点, 我们构造这样一组数据来解释

      image.png

      从这个表可以看出, 第1个分箱的WOE值很低, 第2个分箱的WOE值很高, 如果将这2个WOE值的绝对值相加, 整体的WOE值为4.4, 仍然很高, 这主要是因为第2个分箱的WOE值很高

      但需要注意的是, 第2个分箱的总人数才10个, 占10000个总样本的比例只有 0.1%, 可见样本数据落在第2个分箱的概率本身就比较低, 所以对于整体样本来说, 变量的预测能力并没有那么强

      而在WOE值前乘上($p_{y_i} - p_{n_i}$)后, 其对应的IV值就变得很低, 相当于乘上了一个权重系数, 即考虑了分箱的数据量占整体的比例, 因此IV值更好的体现了分箱比例的影响

      这一点我不这样认为, 可以看到两个分箱的($p_{y_i} - p_{n_i}$)分别为0.8%和0.89%, 相差不过10%左右, 也就是说将WOE值转化为IV值只不过是进行了近似等比例缩小而已, 整体IV仍是由较大的 $IV_i$ 所决定, 这一点从表中最后一列就可以看出, $IV_2$ 为0.0390, 整体IV为0.0391, 整体IV很大程度上仍是由较大的 $IV_i$ 所决定, 这可以作为反驳这里第2点的证据

      一个特征变量的IV值越高, 说明该特征变量越具有区分度, 不过IV值也不是越打越好, 如果一个特征变量的IV值大于0.5, 有时需要对这个特征变量持有疑问, 因为它有点过好而显得不够真实, 通常会选择IV值在0.1~0.5这个范围内的特征变量, 不同应用场景的取值也会有所不同, 比如有些风控团队会将IV值大于0.5的特征变量也纳入考量, 这个其实也需要根据实际的建模效果来做进一步判断

11.5.3 WOE值与IV值的代码实现¶

In [48]:
# 首先通过如下代码构造数据:
import pandas as pd
data = pd.DataFrame([[22,1],[25,1],[20,0],[35,0],[32,1],[38,0],[50,0],[46,1]], columns=['年龄', '是否违约'])
data
Out[48]:
年龄 是否违约
0 22 1
1 25 1
2 20 0
3 35 0
4 32 1
5 38 0
6 50 0
7 46 1
In [49]:
# 有了数据之后,根据“年龄”这一特征变量进行数据分箱,代码如下:
data_cut = pd.cut(data['年龄'], 3)
data_cut
Out[49]:
0    (19.97, 30.0]
1    (19.97, 30.0]
2    (19.97, 30.0]
3     (30.0, 40.0]
4     (30.0, 40.0]
5     (30.0, 40.0]
6     (40.0, 50.0]
7     (40.0, 50.0]
Name: 年龄, dtype: category
Categories (3, interval[float64, right]): [(19.97, 30.0] < (30.0, 40.0] < (40.0, 50.0]]
In [50]:
import warnings

warnings.filterwarnings('ignore')
In [51]:
# 统计总客户数
cut_group_all = data['是否违约'].groupby(data_cut).count()
# 统计违约客户
cut_y = data['是否违约'].groupby(data_cut).sum()
# 统计未违约客户
cut_n = cut_group_all - cut_y
In [52]:
# 这里展示下cut_group_all的结果,如下所示:
cut_group_all
Out[52]:
年龄
(19.97, 30.0]    3
(30.0, 40.0]     3
(40.0, 50.0]     2
Name: 是否违约, dtype: int64
In [53]:
# 通过2.2.1节相关知识点将cut_group_all、cut_y、cut_n进行汇总,代码如下,这里我们将违约客户命名为“坏样本”,非违约客户命名为“好样本”。
df = pd.DataFrame()  # 创建一个空DataFrame用来汇总数据
df['总数'] = cut_group_all
df['坏样本'] = cut_y
df['好样本'] = cut_n
df
Out[53]:
总数 坏样本 好样本
年龄
(19.97, 30.0] 3 2 1
(30.0, 40.0] 3 1 2
(40.0, 50.0] 2 1 1
In [54]:
# 计算坏样本%和好样本%
df['坏样本%'] = df['坏样本'] / df['坏样本'].sum()
df['好样本%'] = df['好样本'] / df['好样本'].sum()
df
Out[54]:
总数 坏样本 好样本 坏样本% 好样本%
年龄
(19.97, 30.0] 3 2 1 0.50 0.25
(30.0, 40.0] 3 1 2 0.25 0.50
(40.0, 50.0] 2 1 1 0.25 0.25
In [55]:
import numpy as np
df['WOE'] = np.log(df['坏样本%'] / df['好样本%'])
df
Out[55]:
总数 坏样本 好样本 坏样本% 好样本% WOE
年龄
(19.97, 30.0] 3 2 1 0.50 0.25 0.693147
(30.0, 40.0] 3 1 2 0.25 0.50 -0.693147
(40.0, 50.0] 2 1 1 0.25 0.25 0.000000

在实际应用中,我们不希望WOE值出现无穷大(这样会导致之后计算的IV值也变为无穷大,丧失了IV值的意义),但是有的时候可能由于数据特殊性及分箱的原因,它还是出现了WOE值为无穷大的情况(某个分箱中只含有一种类别的数据),此时解决办法是当WOE值为无穷大时,将它替换为0,代码如下

In [56]:
df = df.replace({'WOE': {np.inf: 0, -np.inf: 0}})
In [57]:
df['IV'] = df['WOE'] * (df['坏样本%'] - df['好样本%'])
df
Out[57]:
总数 坏样本 好样本 坏样本% 好样本% WOE IV
年龄
(19.97, 30.0] 3 2 1 0.50 0.25 0.693147 0.173287
(30.0, 40.0] 3 1 2 0.25 0.50 -0.693147 0.173287
(40.0, 50.0] 2 1 1 0.25 0.25 0.000000 0.000000
In [58]:
iv = df['IV'].sum()
print(iv)
0.34657359027997264

11.5.4 案例实战: 客户流失预警模型的IV值计算¶

In [59]:
# 将上面的内容首先定义为一个函数
import pandas as pd
import numpy as np

def cal_iv(data, cut_num, feature, target):
    # 1.数据分箱
    data_cut = pd.cut(data[feature], cut_num)

    # 2.统计各个分箱样本总数、坏样本数和好样本数
    cut_group_all = data[target].groupby(data_cut).count()  # 总客户数
    cut_y = data[target].groupby(data_cut).sum()  # 坏样本数
    cut_n = cut_group_all - cut_y  # 好样本数
    # 汇总基础数据
    df = pd.DataFrame()  # 创建一个空DataFrame用来汇总数据
    df['总数'] = cut_group_all
    df['坏样本'] = cut_y
    df['好样本'] = cut_n

    # 3.统计坏样本%和好样本%
    df['坏样本%'] = df['坏样本'] / df['坏样本'].sum()
    df['好样本%'] = df['好样本'] / df['好样本'].sum()

    # 4.计算WOE值
    df['WOE'] = np.log(df['坏样本%'] / df['好样本%'])
    df = df.replace({'WOE': {np.inf: 0, -np.inf: 0}}) 

    # 5.计算各个分箱的IV值
    df['IV'] = df['WOE'] * (df['坏样本%'] - df['好样本%'])

    # 6.汇总各个分箱的IV值,获得特征变量的IV值
    iv = df['IV'].sum()
    
    print(iv)
In [60]:
# 有了上面的自动计算IV值的函数后,通过如下代码来读取客户流失预警模型中的相关数据:
data = pd.read_excel('股票客户流失.xlsx')
data.head()
Out[60]:
账户资金(元) 最后一次交易距今时间(天) 上月交易佣金(元) 本券商使用时长(年) 是否流失
0 22686.5 297 149.25 0 0
1 190055.0 42 284.75 2 0
2 29733.5 233 269.25 0 1
3 185667.5 44 211.50 3 0
4 33648.5 213 353.50 0 1
In [61]:
# 我们利用刚刚编好的函数进行第一个特征变量“账户资金(元)”的IV值计算,代码如下:
cal_iv(data, 4, '账户资金(元)', '是否流失')
0.15205722409339645
In [62]:
for i in data.columns[:-1]: #不需要最后一列目标变量, '-' 是倒数的意思
    print(i + '的IV值为:')
    cal_iv(data, 4, i, '是否流失')  # 调用函数
账户资金(元)的IV值为:
0.15205722409339645
最后一次交易距今时间(天)的IV值为:
0.2508468300174099
上月交易佣金(元)的IV值为:
0.30811632146662304
本券商使用时长(年)的IV值为:
0.6144219248359752

11.6 多重共线性的分析与处理¶

11.6.1 多重共线性的定义¶

  • 对多元线性回归模型 $Y = k_0 + k_1X_1 + k_2X_2 + \cdots + k_nX_n$ 而言, 如果特征变量 $X_1、X_2、X_3 \cdots$ 之间存在高度线性相关关系, 则称为多重共线性

    例如 $X_1 = 1 - X_2$ (比如性别的男和女), 则认为该模型存在多重共线性, 需要删去 $X_1$ 和 $X_2$ 中的一个变量

    如果是多个特征变量, 则多重共线性可以表示成如下公式

    $$ a_1X_1 + a_2X_2 + \cdots + a_nX_n = 0 $$

    如果存在 $a_i$ 不全为0, 即某个特征变量可以用其他特征变量的线性组合表示, 则称特征变量间存在完全共线性

    除了完全共线性, 还存在近似共线性, 它也是多重共线性的一种情况, 其公式如下

    $$ a_1X_1 + a_2X_2 + \cdots + a_nX_n + v = 0 $$

    如果存在 $a_i$ 不全为0, $v$ 为误差随机项, 则称特征变量间存在近似共线性

  • 一个例子

    假设目标变量 $Y$ 代表信用卡额度, 特征变量 $X_1$ 代表月收入, 回归模型为:

    $$ Y = X_1 + 5000 $$

    如果在该回归模型中加入另一个特征变量 $X_2$, 其样本数据恰好与$X_1$完全相同, 那么该回归模型有可能会变成如下形式

    $$ Y = 0.5X_1 + 0.5X_2 + 5000 $$

    特征变量 $X_1$ 的系数从1变成0.5, 这表明收入增加1个单位时, 信用卡额度只会增加0.5个单位, 与实际情况不符, 某种程度上削弱了月收入的特征重要性, 因此, 多重共线性会对回归模型的预测结果产生不利的影响

  • 多重共线性在实际应用中的不利影响

    • 线性回归估计式变得不确定或不精确

    • 线性回归估计式方差变得很大, 标准误差增大

    • 当多重共线性严重时, 甚至可能使估计的回归系数符号相反, 得出错误的结论

    • 削弱特征变量的特征重要性

11.6.2 多重共线性的分析与检验¶

In [63]:
# 使用Pandas库读入一组存在多重共线性的数据,并对其回归作为示例:
import pandas as pd
df = pd.read_excel('数据.xlsx')
df.head()
Out[63]:
X1 X2 X3 Y
0 8 16 -32 77
1 7 14 -31 52
2 4 9 -12 42
3 1 2 5 6
4 1 2 8 19
In [64]:
# 对数据集划分特征变量和目标变量:
X = df.drop(columns='Y')
Y = df['Y']
  1. 相关系数判断
In [65]:
X.corr()
Out[65]:
X1 X2 X3
X1 1.000000 0.992956 -0.422788
X2 0.992956 1.000000 -0.410412
X3 -0.422788 -0.410412 1.000000
  1. 方差膨胀系数法(VIF检验)
  • 方差膨胀系数 (Varience Inflation Factor) 的计算公式为

    $$ VIF_i = \frac{1}{1 - R_i^2} $$

    $R_i^2$ 是将自变量 $X_i$ 作为因变量, 其他自变量作为特征变量时回归的可决系数, 即 $X_i = \beta X_{-i} + \varepsilon$ 的 $R^2$, $R_i^2$ 越大, $VIF_i$ 就越大, 表示自变量 $X_i$ 与其他自变量间的多重共线性越严重

    一般认为 $VIF_i < 10$ 时, 该自变量与其余自变量之间不存在多重共线性 ($R_i^2 < 0.9$); 当 $10 \leqslant VIF_i < 100$ 时, 该自变量与其余自变量之间不存在多重共线性; 超过100时存在严重的多重共线性

In [66]:
# 为了检验上述回归中是否存在严重的多重共线性,使用Python的VIF检验模块来验证:
from statsmodels.stats.outliers_influence import variance_inflation_factor
vif = [variance_inflation_factor(X.values, X.columns.get_loc(i)) for i in X.columns]
In [67]:
vif
Out[67]:
[259.6430487184967, 257.6315718292196, 1.302330632715429]
In [68]:
# 如果对上面的快捷写法不太理解,上面的代码也可以写成:
vif = []
for i in X.columns:  # i对应的是每一列的列名
    vif.append(variance_inflation_factor(X.values, X.columns.get_loc(i)))
    
vif
Out[68]:
[259.6430487184967, 257.6315718292196, 1.302330632715429]

因为特征变量 $X_2$ 是 $X_1$ 的2倍, 所以使用 $X_1$ 对 $X_2$ 和 $X_3$ 回归和使用 $X_2$ 对 $X_1$ 和 $X_3$ 回归时所得的方差膨胀系数会很大, 从上述计算结果也可以看出, 前2个VIF值均大于100, 暗示多重共线性十分严重, 应该删掉 $X_1$ 或 $X_2$

删掉 $X_2$ 再进行一次回归和VIF检验, 查看结果变化

In [69]:
# 对数据集重新划分特征变量和目标变量:
X = df[['X1', 'X3']]
Y = df['Y']

# 进行检验VIF检验:
from statsmodels.stats.outliers_influence import variance_inflation_factor
vif = [variance_inflation_factor(X.values, X.columns.get_loc(i)) for i in X.columns]

vif
Out[69]:
[1.289349054516766, 1.289349054516766]

11.7 过采样和欠采样¶

  • 建立模型时,可能会遇到正负样本比例极度不均衡的情况

    例如,建立信用违约模型时,违约样本的比例远小于不违约样本的比例,此时模型会花更多精力去拟合不违约样本,但实际上找出违约样本更为重要, 这会导致模型可能在训练集上表现良好,但测试时表现不佳, 为了改善样本比例不均衡的问题,可以使用过采样和欠采样的方法

    假设建立信用违约模型时,样本数据中有1000个不违约样本和100个违约样本,下面分别介绍过采样和欠采样的方法

11.7.1 过采样¶

  1. 过采样的原理

    过采样的方法有随机过采样和SMOTE法过采样

    • 随机过采样

      随机过采样是从100个违约样本中随机抽取旧样本作为一个新样本, 共反复抽取900次, 然后和原来的100个旧样本组合成新的1000个违约样本, 和1000个不违约样本一起构成新的训练集, 因为随机过采样重复地选取了违约样本, 所以有可能造成对违约样本的过拟合

    • SMOTE法过采样

      SMOTE法过采样即合成少数类过采样技术, 它是一种针对随机过采样容易导致过拟合问题的改进方案

      假设对少数类进行4倍过采样, 通过下图来说明SMOTE法的原理

      image.png

  1. 代码实现
In [70]:
import pandas as pd
data = pd.read_excel("信用卡数据.xlsx")
data.head()
Out[70]:
编号 年龄 负债比率 月收入 贷款数量 家属人数 分类
0 1 29 0.22 7800 1 3 0
1 2 52 0.46 4650 1 0 0
2 3 28 0.10 3000 0 0 0
3 4 29 0.20 5916 0 0 0
4 5 27 1.28 1300 0 0 1
In [71]:
# 通过如下代码提取特征变量并将其赋值给变量X,提取目标变量并将其赋值给变量y:。
X = data.drop(columns='分类')
y = data['分类']
In [72]:
# 然后使用collections库中的Counter()方法,对目标变量进行计数:
from collections import Counter
Counter(y)
Out[72]:
Counter({0: 1000, 1: 100})
In [73]:
# 随机过采样

from imblearn.over_sampling import RandomOverSampler
ros = RandomOverSampler(random_state=0)
X_oversampled, y_oversampled = ros.fit_resample(X, y)
In [74]:
# 用Counter()方法检验一下SMOTE过采样的效果。
Counter(y_oversampled)
Out[74]:
Counter({0: 1000, 1: 1000})
In [75]:
# SMOTE过采样

from imblearn.over_sampling import SMOTE
smote = SMOTE(random_state=0)
X_smotesampled, y_smotesampled = smote.fit_resample(X, y)
In [76]:
# 用Counter()方法检验一下SMOTE过采样的效果。
Counter(y_smotesampled)
Out[76]:
Counter({0: 1000, 1: 1000})

11.7.2 欠采样¶

  1. 欠采样的原理
  • 欠采样是从1000个不违约样本中随机选取100个样本, 和100个违约样本一起构成新的训练集

    欠采样抛弃了大部分不违约样本, 在搭建模型时有可能产生欠拟合

  1. 欠采样的代码实现
In [77]:
# 仍然采用上方的信用违约数据进行欠采样代码的展示:
from imblearn.under_sampling import RandomUnderSampler
rus = RandomUnderSampler(random_state=0)
X_undersampled, y_undersampled = rus.fit_resample(X, y)
In [78]:
# 用Counter()方法检验一下随机欠采样的效果。
Counter(y_undersampled)
Out[78]:
Counter({0: 100, 1: 100})
In [79]:
# 不违约的样本数从1000下降至违约的样本数100,这证明我们的随机欠采样有效。同时我们可以打印特征变量X_undersampled的shape来看看特征变量的变化:
X_undersampled.shape
Out[79]:
(200, 6)
  • 在实战中处理样本不均衡问题时,如果样本数据量不大,通常使用过采样

    因为这样能更好地利用数据,不会像欠采样那样很多数据都没有使用到

    如果数据量充足,则过采样和欠采样都可以考虑使用